Skip to content

Make router health checks concurrent, bounded, and non-blocking#491

Merged
Haven Xia (HavenXia) merged 3 commits into
agent-substrate:mainfrom
YQ-Wang:feat/490-router-health-timeout
Jul 24, 2026
Merged

Make router health checks concurrent, bounded, and non-blocking#491
Haven Xia (HavenXia) merged 3 commits into
agent-substrate:mainfrom
YQ-Wang:feat/490-router-health-timeout

Conversation

@YQ-Wang

@YQ-Wang Yiqing Wang (YQ-Wang) commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • run the Envoy, Kubernetes API, and ATE API dependency checks concurrently, so each health cycle is bounded by the slowest check (about 500 ms) instead of their cumulative time (up to about 1.5 s)
  • make the Kubernetes /version health check observe the router context and a 500 ms dependency timeout
  • perform dependency network calls without holding the health report mutex, allowing /statusz to serve the most recently completed report
  • preserve health timestamps and counters with an atomic report update after each concurrent check cycle
  • add regression coverage for concurrency, timeout, cancellation, report/status responsiveness, and health accounting

Root cause

The client-go Discovery().ServerVersion() helper uses context.TODO() internally. If the Kubernetes API accepted /version but withheld its response, canceling the router context could not terminate the request. The router also held the report mutex across dependency network calls, so the stalled request blocked /statusz and delayed shutdown.

The three dependency checks also ran sequentially. Even with a 500 ms timeout per dependency, one health cycle could therefore take up to about 1.5 seconds. Running them concurrently bounds the cycle by the slowest check, about 500 ms.

Validation

  • make verify
  • go test ./cmd/atenet/internal/router -run 'Test(CheckK8s|HealthCheck|HealthStart)' -count=20
  • go test -race ./cmd/atenet/internal/router -count=1
  • Kind Kubernetes v1.36.1 fault injection: a proxy fetched the real cluster /version response and withheld it; /statusz remained responsive with HTTP 200, the request was canceled at the 500 ms deadline, and one Kubernetes health failure was recorded

Fixes #490

@YQ-Wang
Yiqing Wang (YQ-Wang) marked this pull request as ready for review July 22, 2026 09:21

@haiyanmeng haiyanmeng left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yiqing Wang (@YQ-Wang) , thanks for the great work!

Just one comment, should we consider running checkEnvoy, checkK8s, and checkAteAPI concurrently? This could limit the time of each check call to ~500ms instead of 1500ms.

@HavenXia

Copy link
Copy Markdown
Collaborator

Just curious: should we add liveness/readiness for router itself?

@YQ-Wang

Copy link
Copy Markdown
Contributor Author

haiyanmeng Good suggestion, thank you. I updated the three dependency checks to run concurrently, so each health cycle is now bounded by the slowest check (about 500 ms). I also added a regression test for this.

@YQ-Wang

Copy link
Copy Markdown
Contributor Author

Haven Xia (@HavenXia) Good point. Dedicated router liveness and readiness probes would be useful, but I think they are better handled in a follow-up with lightweight /livez and /readyz endpoints and clearly defined semantics, rather than reusing /statusz.

@haiyanmeng haiyanmeng left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making the checks concurrent.

One tiny comment, otherwise looks good to me.

Comment thread cmd/atenet/internal/router/health.go Outdated
Comment on lines 124 to 132
if !envoyResult.healthy {
slog.ErrorContext(ctx, "Envoy health check failed", slog.String("msg", envoyResult.message))
}
if !k8sResult.healthy {
slog.ErrorContext(ctx, "Kubernetes API health check failed", slog.String("msg", k8sResult.message))
}
if !ateResult.healthy {
slog.ErrorContext(ctx, "ATE API gRPC health check failed", slog.String("msg", ateResult.message))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this logic to runComponentHealthCheck? It would look like:

func runComponentHealthCheck(ctx context.Context, failMsg string, check func(context.Context) (bool, string)) componentHealthCheckResult {
	healthy, message := check(ctx)
	if !healthy {
		slog.ErrorContext(ctx, failMsg, slog.String("msg", message))
	}
	return componentHealthCheckResult{
		healthy:   healthy,
		message:   message,
		checkedAt: time.Now(),
	}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@haiyanmeng

Copy link
Copy Markdown

Yiqing Wang (@YQ-Wang) , can you update the PR title and description to make it clear that it also makes the checks concurrent? Thanks!

@YQ-Wang Yiqing Wang (YQ-Wang) changed the title Make router health checks bounded and non-blocking Make router health checks concurrent, bounded, and non-blocking Jul 24, 2026
@YQ-Wang

Copy link
Copy Markdown
Contributor Author

Yiqing Wang (Yiqing Wang (@YQ-Wang)) , can you update the PR title and description to make it clear that it also makes the checks concurrent? Thanks!

haiyanmeng done, thanks!

@haiyanmeng haiyanmeng left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm
/approve

@HavenXia

Copy link
Copy Markdown
Collaborator

Help merge before you have membership propagated :D

@HavenXia
Haven Xia (HavenXia) merged commit 10c6ec2 into agent-substrate:main Jul 24, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make router Kubernetes health checks bounded and non-blocking

4 participants